Skip to content

Add FileSystemClient: System.IO-style async client over OPC UA file APIs#3760

Merged
marcschier merged 6 commits into
OPCFoundation:masterfrom
marcschier:fsclient
May 14, 2026
Merged

Add FileSystemClient: System.IO-style async client over OPC UA file APIs#3760
marcschier merged 6 commits into
OPCFoundation:masterfrom
marcschier:fsclient

Conversation

@marcschier
Copy link
Copy Markdown
Collaborator

Proposed changes

This PR adds a new FileSystemClient to Libraries/Opc.Ua.Client/FileSystem/ that turns the source-generated FileTypeClient / FileDirectoryTypeClient / TemporaryFileTransferTypeClient proxies (already emitted into Opc.Ua.Core from the standard NodeSet) into an ergonomic, System.IO-style async-only client over OPC UA file systems (Part 5 Annex C, Part 20 §4).

The public surface mirrors System.IO:

New type Mirrors
FileSystemClient System.IO.Directory + System.IO.File
UaFileSystemInfo (abstract) → UaFileInfo / UaDirectoryInfo FileSystemInfoFileInfo / DirectoryInfo
UaFileStream : System.IO.Stream FileStream (async + sync forwarders)
UaPath (public static) System.IO.Path
FileSystemClientOptions n/a (tuning)
TemporaryFileTransferClient + UaTemporaryWriteFile n/a (Part 5 §C.5)

Design highlights

  • Path syntax: forward-slash '/' only; each segment parsed via QualifiedName.Parse so "1:Reports/2024/data.csv" works. Canonical paths preserve the namespace index so siblings with the same .Name in different namespaces never collapse.
  • Path resolution: TranslateBrowsePathsToNodeIds per segment with a small LRU cache keyed by (parent NodeId, browse name). Cache hits are best-effort; BadNodeIdUnknown evicts and triggers exactly one re-resolution.
  • Type classification: Session.TypeTree.IsTypeOf (subtype-aware by default, so TrustListType, AddressSpaceFileType, etc. show up as files); enumeration filters NodeClass=Object + hierarchical references with IncludeSubtypes=true.
  • Streams: UaFileStream chunks Reads/Writes at FileSystemClientOptions.ChunkSize (clamped to FileType.MaxByteStringLength when known); empty ByteString = EOF; zero-length read/write never hits the wire; Position tracked locally with lazy SetPositionAsync; DisposeAsync issues Close exactly once. Sync members forward to async via GetAwaiter().GetResult().
  • Error mapping at the public boundary: BadNoMatch / BadNotFound / BadNodeIdUnknownFileNotFoundException / DirectoryNotFoundException; BadBrowseNameDuplicatedIOException("already exists"); BadUserAccessDenied / BadNotWritableUnauthorizedAccessException; resource/state codes → IOException; everything else propagates as ServiceResultException.
  • DisposeAsync pattern from https://learn.microsoft.com/dotnet/standard/garbage-collection/implementing-disposeasync applied throughout (DisposeAsyncDisposeAsyncCoreDispose(bool)SuppressFinalize); await using consumers use the await using (x.ConfigureAwait(false)) block-scope form so CA2007 is satisfied.
  • DeleteAsync(recursive: false) on a directory enumerates first and throws IOException when non-empty; recursive: true invokes the server's Delete exactly once (per Part 20 §4.3 the server's primitive is recursive — the client never walks the tree itself).
  • CreateFileAsync always passes requestFileOpen: false so server-allocated handles never leak through the create call. Leaf segments with a namespace prefix are rejected with ArgumentException since the server picks the BrowseName namespace.
  • Temporary-file-transfer: UaTemporaryWriteFile owns the close lifecycle — exactly one terminal call (CommitAsync = CloseAndCommit, OR DisposeAsync = Close, server rollback). The wrapped Stream cannot accidentally close the server handle.

Tests

111 unit tests under Tests/Opc.Ua.Client.Tests/FileSystem/, all mock-based against ISessionClient / ISession (no live server required):

Suite Count Coverage
UaPathTests 28 Combine / GetDirectoryName / GetFileName / Parse + namespace round-trip
PathCacheTests 13 LRU eviction + invalidation
FileSystemErrorsTests 13 StatusCode → exception mapping
UaFileStreamTests 15 Chunking, EOF, position-sync, dispose-once, sync↔async parity, correct method-id wiring
FileSystemClientOptionsTests 6 Defaults / clone / validation
TemporaryFileTransferClientTests 4 Commit-then-dispose, dispose-without-commit, generate-for-read close, stream-wrapper non-closing
FileSystemClientPathResolutionTests 11 Resolution, missing path, ambiguity, type mismatch, cache hit
FileSystemClientEnumerationTests 6 Mixed/files-only/dirs-only filtering, unknown types skipped, FullPath propagation
FileSystemClientMetadataTests 3 Mandatory + optional + missing-optional RefreshAsync
FileSystemClientCrudTests 12 Correct CallMethodRequest construction, recursive vs non-recursive delete, namespace-prefix rejection, error mapping

Build verification:

  • dotnet build on Opc.Ua.Client.csproj and Opc.Ua.Client.Tests.csproj0 errors, 0 warnings across all 6 TFMs (net472, net48, netstandard2.1, net8.0, net9.0, net10.0).
  • dotnet format style + whitespace + analyzers --severity info --verify-no-changes — exit 0 on both projects.
  • All 111 FileSystem tests pass on net10.0.

Documentation

  • New Docs/FileSystemClient.md (~12 KB): overview, getting started, path syntax, error mapping table, recursive-delete semantics, temporary-file-transfer flow.
  • One-line cross-reference added to Tools/Opc.Ua.SourceGeneration/readme.md "ObjectType client proxies" section.

Repository agent

Bonus: this PR also adds .github/agents/dotnet-format.agent.md — a repo agent that scopes dotnet format (whitespace + style + analyzers) to user-specified files and chases remaining CA/IDE/RCS warnings to a 0-warning build. Includes a per-warning fix cookbook and the DisposeAsync pattern documentation.

Related Issues

  • Fixes #

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Enhancement (non-breaking change which adds functionality)
  • Test enhancement (non-breaking change to increase test coverage)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected, requires version increase of Nuget packages)
  • Documentation Update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc.
  • I have signed the CLA.
  • I ran tests locally with my changes, all passed.
  • I fixed all failing tests in the CI pipelines.
  • I fixed all introduced issues with CodeQL and LGTM.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added necessary documentation (if appropriate).
  • Any dependent changes have been merged and published in downstream modules.

Further comments

The implementation is purely additive — no existing files are modified except for a one-line cross-reference in Tools/Opc.Ua.SourceGeneration/readme.md. Everything else lives in new files under Libraries/Opc.Ua.Client/FileSystem/, Tests/Opc.Ua.Client.Tests/FileSystem/, Docs/, and .github/agents/.

Layered architecture choice: FileSystemClient deliberately stays out of the Opc.Ua namespace (lives in Opc.Ua.Client.FileSystem) so it does not mix the System.IO-style abstractions with the raw OPC UA service surface. TemporaryFileTransferClient is a separate sibling type because its lifecycle (server-allocated transient file + commit/rollback) does not fit the System.IO model.

marcschier and others added 2 commits May 13, 2026 10:46
Implements Libraries/Opc.Ua.Client/FileSystem/ on top of the source-generated FileTypeClient, FileDirectoryTypeClient and TemporaryFileTransferTypeClient proxies in Opc.Ua.Core (Part 5 Annex C / Part 20 Section 4).

Public surface mirrors System.IO: FileSystemClient (entry point), UaFileSystemInfo + UaFileInfo + UaDirectoryInfo, UaFileStream : System.IO.Stream (async + sync forwarders), UaPath. Separate TemporaryFileTransferClient + UaTemporaryWriteFile for Part 5 Section C.5 with single-terminal-call commit lifecycle.

Path syntax is forward-slash only with namespace-aware QualifiedName segments. Path resolution caches resolved (parent, name)->child NodeIds in a small LRU. Type-aware enumeration via Session.TypeTree.IsTypeOf (subtype-aware by default). UaFileStream chunks Read/Write at FileSystemClientOptions.ChunkSize (clamped to MaxByteStringLength), tracks Length/Position locally, pushes SetPosition lazily, issues Close exactly once.

DisposeAsync follows the recommended pattern from https://learn.microsoft.com/dotnet/standard/garbage-collection/implementing-disposeasync. Status codes (BadNoMatch, BadNotFound, BadBrowseNameDuplicated, BadUserAccessDenied, BadNotWritable, ...) are translated to FileNotFoundException / DirectoryNotFoundException / UnauthorizedAccessException / IOException at the public boundary.

Tests: 111 unit tests under Tests/Opc.Ua.Client.Tests/FileSystem/ covering UaPath (28), PathCache (13), FileSystemErrors (13), UaFileStream (15), FileSystemClientOptions (6), TemporaryFileTransferClient (4), FileSystemClient path resolution (11), enumeration (6), metadata (3), and CRUD (12). All mock-based (Moq + a FileSystemSessionHarness fake address space).

Docs/FileSystemClient.md describes the public API, path syntax, error mapping table, recursive-delete semantics, and the temporary-file-transfer flow.

0 errors, 0 warnings on Opc.Ua.Client and Opc.Ua.Client.Tests across all 6 TFMs (net472, net48, netstandard2.1, net8.0, net9.0, net10.0).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New repo agent that scopes 'dotnet format' (whitespace + style + analyzers --severity info) to user-specified files, then chases remaining CA/IDE/RCS warnings to a 0-warning build. Includes a per-warning-code fix cookbook (CA1835, CA2007, CA2213, CA2215, CA1844, CA1861, CA1068, CA1859, CA1307/CA2249, RCS1007, RCS1135, RCS1166), the recommended DisposeAsync pattern from the .NET docs, and a list of anti-patterns observed when applying the same workflow manually.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 68.01587% with 403 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.09%. Comparing base (96f8964) to head (cbe9a8c).

Files with missing lines Patch % Lines
...aries/Opc.Ua.Client/FileSystem/FileSystemClient.cs 69.37% 138 Missing and 43 partials ⚠️
Libraries/Opc.Ua.Client/FileSystem/UaFileInfo.cs 18.36% 80 Missing ⚠️
Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs 66.66% 47 Missing and 16 partials ⚠️
...s/Opc.Ua.Client/FileSystem/UaTemporaryWriteFile.cs 57.31% 30 Missing and 5 partials ⚠️
...aries/Opc.Ua.Client/FileSystem/UaFileSystemInfo.cs 9.09% 18 Missing and 2 partials ⚠️
...a.Client/FileSystem/TemporaryFileTransferClient.cs 78.26% 9 Missing and 1 partial ⚠️
Libraries/Opc.Ua.Client/FileSystem/PathCache.cs 88.57% 3 Missing and 5 partials ⚠️
...raries/Opc.Ua.Client/FileSystem/UaDirectoryInfo.cs 83.33% 4 Missing ⚠️
...aries/Opc.Ua.Client/FileSystem/FileSystemErrors.cs 95.12% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3760      +/-   ##
==========================================
- Coverage   72.14%   72.09%   -0.06%     
==========================================
  Files         597      608      +11     
  Lines      122192   123452    +1260     
  Branches    20582    20796     +214     
==========================================
+ Hits        88154    89000     +846     
- Misses      27997    28336     +339     
- Partials     6041     6116      +75     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs Outdated
marcschier and others added 3 commits May 13, 2026 11:48
The test asserts disposedDelta == createdDelta on the process-wide Certificate.InstancesCreated / InstancesDisposed counters, but the owning fixture is [Parallelizable]. Any other test in the Opc.Ua.Core.Tests assembly (6729 tests) that allocates a Certificate during the snapshot window inflates createdDelta without a matching disposedDelta, intermittently failing the assertion on the Windows CI runner (the race is platform-sensitive — Ubuntu hits a different schedule and usually misses it).

Marking the single test [NonParallelizable] gives it exclusive access to the counters for its ~267 ms run. The rest of the fixture stays [Parallelizable], so the wall-clock impact is negligible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ReadCoreAsync: check ByteString.IsEmpty for EOF before allocating; copy via data.Span.CopyTo(buffer.AsSpan(...)) instead of materialising a temp byte[] via ToArray() + Buffer.BlockCopy.

WriteCoreAsync: same optimization on the symmetric path — wrap the caller's slice via the zero-copy 'new ByteString(buffer.AsMemory(offset, length))' constructor. The encoder copies the bytes onto the wire during WriteAsync; the await guarantees the buffer is not reused before the request is fully serialised, so wrapping (without an explicit copy) is safe.

All 111 FileSystem unit tests still pass on net10.0.

Addresses feedback from @marcschier on OPCFoundation#3760 (comment)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removed conditional compilation for older platforms and the associated NoOp test.
@marcschier marcschier merged commit 3836cbf into OPCFoundation:master May 14, 2026
101 of 103 checks passed
@marcschier marcschier deleted the fsclient branch May 14, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants